Skip to main content

copp\copp\copp2\dp2/
topp2_ra.rs

1//! Reachability-analysis solver for second-order time-optimal path parameterization.
2//!
3//! # Method identity
4//! This module implements **Reachability Analysis (RA)** for
5//! **Time-Optimal Path Parameterization (TOPP2)** and shares the same state-space
6//! conventions used by **Convex-Objective Path Parameterization (COPP2)** components.
7//!
8//! # Discrete variables (local notation)
9//! On a path grid `s[0..=n]`:
10//! - `a[k]` denotes $\dot{s}_k^2$;
11//! - backward intervals are `[a_min[k], a_max[k]]` from [`reach_set2_backward`](crate::solver::reach_set2::reach_set2_backward);
12//! - forward pass selects one feasible state per station, yielding the final profile `a`.
13//!
14//! # High-level pipeline
15//! 1. Build backward reachable intervals by calling [`reach_set2_backward`](crate::solver::reach_set2::reach_set2_backward).
16//! 2. Run forward clipping against local constraints and backward intervals.
17//! 3. Select maximal feasible `a[k]` at each stage to recover the time-optimal profile.
18
19use super::reach_set2::{ReachSet2Options, reach_set2_backward};
20use crate::copp::copp2::formulation::Topp2Problem;
21use crate::copp::{ApproxOrdering, approx_order};
22use crate::diag::{
23    CoppError, DebugVerboser, SilentVerboser, SummaryVerboser, TraceVerboser, Verboser, Verbosity,
24    format_duration_human,
25};
26use crate::math::numerical::{LpToleranceOptions, lp_1d};
27use core::f64;
28use itertools::izip;
29
30/// Solve TOPP2 with RA and return the profile $a(s)=\dot{s}^2$.
31///
32/// # Returns
33/// Returns `a` such that:
34/// - `a[0] = a_start`, `a[n] = a_final`, where `n = idx_s_final - idx_s_start`;
35/// - `a` is time-optimal under configured first-/second-order constraints.
36///
37/// The returned profile can be mapped to `t(s)` by
38/// [`s_to_t_topp2`](crate::solver::topp2_ra::s_to_t_topp2), then to sampled
39/// `s(t)` by [`t_to_s_topp2`](crate::solver::topp2_ra::t_to_s_topp2).
40///
41/// # Errors
42/// Returns [`CoppError`](crate::diag::CoppError) when backward reachable-set construction fails or when
43/// forward pass cannot maintain feasibility under constraints.
44///
45/// # Contract
46/// - station interval and boundary states must be valid for the given constraints;
47/// - `options` must contain valid tolerance settings.
48pub fn topp2_ra(problem: &Topp2Problem, options: &ReachSet2Options) -> Result<Vec<f64>, CoppError> {
49    match options.verbosity {
50        Verbosity::Silent => topp2_ra_core(problem, (options, SilentVerboser)),
51        Verbosity::Summary => topp2_ra_core(problem, (options, SummaryVerboser::new())),
52        Verbosity::Debug => topp2_ra_core(problem, (options, DebugVerboser::new())),
53        Verbosity::Trace => topp2_ra_core(problem, (options, TraceVerboser::new())),
54    }
55}
56
57/// Recover the largest state in the current backward interval whose edge from
58/// `a_prev` satisfies every original TOPP2 row within the configured
59/// unit-normal tolerance and an independent mixed physical-residual cap.
60///
61/// The ordinary forward reduction solves `row.0 * a_curr <= row.2 -
62/// row.1 * a_prev`.  When `row.0` is the tiny remainder of a cancellation, a
63/// last-bit residual in `a_prev` can become a macroscopic interval gap after
64/// division.  This cold path avoids that ill-conditioned division as its first
65/// decision: it certifies the backward upper endpoint directly.  If necessary,
66/// it then intersects the tolerance-expanded half-spaces at fixed `a_prev` and
67/// retries their largest point.
68///
69/// Returning a point is a certificate, not a best-effort fallback: the new
70/// point is strictly inside its backward/path bounds, the already-committed
71/// predecessor is inside its bounds under the configured state comparison
72/// tolerance, and every finite row has been checked again in normalized 2D
73/// coordinates.  A second mixed absolute/relative check bounds the
74/// unnormalized physical residual even when the row normal is large.  Failure
75/// leaves the caller's original infeasibility path unchanged.
76#[cold]
77#[inline(never)]
78#[allow(clippy::too_many_arguments)]
79fn recover_forward_intersection(
80    rows: &[(f64, f64, f64)],
81    a_prev: f64,
82    prev_backward: (f64, f64),
83    curr_backward: (f64, f64),
84    prev_path_max: f64,
85    curr_path_max: f64,
86    options: &ReachSet2Options,
87) -> Option<(f64, f64, f64)> {
88    let feas_tol = options.lp_feas_tol;
89    if !a_prev.is_finite()
90        || !feas_tol.is_finite()
91        || feas_tol < 0.0
92        || !prev_backward.0.is_finite()
93        || !prev_backward.1.is_finite()
94        || curr_backward.0.is_nan()
95        || curr_backward.1.is_nan()
96        || prev_path_max.is_nan()
97        || curr_path_max.is_nan()
98    {
99        return None;
100    }
101
102    let inside = |value: f64, lower: f64, upper: f64| {
103        value.is_finite()
104            && !matches!(
105                approx_order(value, lower, options.a_cmp_abs_tol, options.a_cmp_rel_tol,),
106                ApproxOrdering::Less
107            )
108            && !matches!(
109                approx_order(value, upper, options.a_cmp_abs_tol, options.a_cmp_rel_tol,),
110                ApproxOrdering::Greater
111            )
112    };
113    let prev_lower = prev_backward.0.max(0.0);
114    let prev_upper = prev_backward.1.min(prev_path_max);
115    if !prev_lower.is_finite() || !prev_upper.is_finite() || !inside(a_prev, prev_lower, prev_upper)
116    {
117        return None;
118    }
119
120    // Use strict bounds for the point that will actually be committed.  The
121    // mixed comparison tolerance above only certifies the already-selected
122    // predecessor against its stored interval.
123    let lower = curr_backward.0.max(0.0);
124    let upper = curr_backward.1.min(curr_path_max);
125    if !lower.is_finite() || !upper.is_finite() || lower > upper {
126        return None;
127    }
128
129    let certify = |a_curr: f64| {
130        if !a_curr.is_finite() || a_curr < lower || a_curr > upper {
131            return None;
132        }
133        let mut worst_normalized = 0.0_f64;
134        let mut worst_raw = 0.0_f64;
135        for &(coef_curr, coef_prev, rhs) in rows {
136            if !coef_curr.is_finite() || !coef_prev.is_finite() || !rhs.is_finite() {
137                return None;
138            }
139            let norm = coef_curr.hypot(coef_prev);
140            if norm == 0.0 {
141                // A directionless physical row is feasible only when 0 <= rhs.
142                if rhs < 0.0 {
143                    return None;
144                }
145                continue;
146            }
147            if !norm.is_finite() {
148                return None;
149            }
150            let norm_inv = 1.0 / norm;
151            let raw_tol = feas_tol * rhs.abs().max(1.0);
152            if !raw_tol.is_finite() {
153                return None;
154            }
155            let normalized_residual =
156                (coef_curr * norm_inv) * a_curr + (coef_prev * norm_inv) * a_prev - rhs * norm_inv;
157            let raw_residual = coef_curr * a_curr + coef_prev * a_prev - rhs;
158            let raw_normalized_residual = raw_residual / norm;
159            if !normalized_residual.is_finite()
160                || !raw_residual.is_finite()
161                || !raw_normalized_residual.is_finite()
162                || normalized_residual > feas_tol
163                || raw_normalized_residual > feas_tol
164                || raw_residual > raw_tol
165            {
166                return None;
167            }
168            worst_normalized = worst_normalized
169                .max(normalized_residual)
170                .max(raw_normalized_residual);
171            worst_raw = worst_raw.max(raw_residual);
172        }
173        Some((a_curr, worst_normalized, worst_raw))
174    };
175
176    // This is both the cheapest and the strongest recovery: attaining the
177    // backward cap preserves the greedy stage optimum exactly.
178    if let Some(certified) = certify(upper) {
179        return Some(certified);
180    }
181
182    // Search only after the cap itself failed its certificate.  At fixed
183    // `a_prev`, each normalized 2D half-space cuts out a 1D interval.  Their
184    // intersection with the backward interval therefore gives the complete
185    // tolerance-feasible candidate set; its upper endpoint is the maximizer.
186    let mut projected_lower = lower;
187    let mut projected_upper = upper;
188    for &(coef_curr, coef_prev, rhs) in rows {
189        let norm = coef_curr.hypot(coef_prev);
190        if norm == 0.0 {
191            if rhs < 0.0 {
192                return None;
193            }
194            continue;
195        }
196        if !norm.is_finite() {
197            return None;
198        }
199        let norm_inv = 1.0 / norm;
200        let raw_tol = feas_tol * rhs.abs().max(1.0);
201        if !raw_tol.is_finite() {
202            return None;
203        }
204        let normalized_tol = feas_tol.min(raw_tol * norm_inv);
205        let coef_curr = coef_curr * norm_inv;
206        let coef_prev = coef_prev * norm_inv;
207        let rhs = rhs * norm_inv + normalized_tol - coef_prev * a_prev;
208        if !rhs.is_finite() {
209            return None;
210        }
211        if coef_curr > 0.0 {
212            projected_upper = projected_upper.min(rhs / coef_curr);
213        } else if coef_curr < 0.0 {
214            projected_lower = projected_lower.max(rhs / coef_curr);
215        } else if rhs < 0.0 {
216            return None;
217        }
218    }
219    if !projected_lower.is_finite()
220        || !projected_upper.is_finite()
221        || projected_lower > projected_upper
222    {
223        return None;
224    }
225
226    // A division can put the endpoint outside the certificate by much more
227    // than one x-ulp when its normalized coefficient is tiny: one residual ulp
228    // is then many coordinate ulps.  Try the immediate neighbours first, then
229    // establish an interior certified point and bisect back toward the upper
230    // endpoint.  Every retained point has passed the complete row certificate.
231    let mut candidate = projected_upper;
232    for _ in 0..=4 {
233        if candidate < projected_lower {
234            break;
235        }
236        if let Some(certified) = certify(candidate) {
237            return Some(certified);
238        }
239        candidate = candidate.next_down();
240    }
241
242    let mut certified_lower_x = 0.5 * projected_lower + 0.5 * projected_upper;
243    let mut certified_lower = certify(certified_lower_x)?;
244    let mut rejected_upper = projected_upper;
245    for _ in 0..64 {
246        let midpoint = 0.5 * certified_lower_x + 0.5 * rejected_upper;
247        if midpoint == certified_lower_x || midpoint == rejected_upper {
248            break;
249        }
250        if let Some(certified) = certify(midpoint) {
251            certified_lower_x = midpoint;
252            certified_lower = certified;
253        } else {
254            rejected_upper = midpoint;
255        }
256    }
257    Some(certified_lower)
258}
259
260/// Core implementation of Reachability Analysis for TOPP2 with layered verbosity logging.
261fn topp2_ra_core(
262    problem: &Topp2Problem,
263    options_verboser: (&ReachSet2Options, impl Verboser),
264) -> Result<Vec<f64>, CoppError> {
265    let (options, mut verboser) = options_verboser;
266    if verboser.is_enabled(Verbosity::Summary) {
267        verboser.record_start_time();
268        crate::verbosity_log!(
269            Verbosity::Summary,
270            "\ntopp2_ra started: {} <= idx_s <= {}, a_start = {}, a_final = {}.",
271            problem.idx_s_interval.0,
272            problem.idx_s_interval.1,
273            problem.a_boundary.0,
274            problem.a_boundary.1,
275        );
276    }
277
278    // Step 1. Compute the backward reachable set.
279    let reach_set = reach_set2_backward(problem, options).map_err(|e| {
280        if verboser.is_enabled(Verbosity::Debug) {
281            crate::verbosity_log!(Verbosity::Debug, "{e:?}");
282        } else if verboser.is_enabled(Verbosity::Summary) {
283            crate::verbosity_log!(
284                Verbosity::Summary,
285                "topp2_ra: failed while computing backward reachable set."
286            );
287        }
288        e
289    })?;
290    let a_max = &reach_set.a_max;
291    let a_min = &reach_set.a_min;
292
293    // Step 2. Forward pass to select the maximal feasible state at each grid point.
294    if verboser.is_enabled(Verbosity::Debug) {
295        crate::verbosity_log!(Verbosity::Debug, "Forward pass started.");
296    }
297
298    let (idx_s_start, idx_s_final) = problem.idx_s_interval;
299    let n = idx_s_final - idx_s_start;
300    let mut a = vec![0.0; n + 1];
301    let mut a_prev = problem.a_boundary.0;
302    *a.first_mut().unwrap() = a_prev;
303
304    let mut a_b = Vec::<(f64, f64, f64)>::with_capacity(2 * problem.constraints.acc_rows());
305    for (k, (a_curr, &a_max_curr_, &a_min_curr_)) in
306        izip!(a.iter_mut(), a_max, a_min).enumerate().skip(1)
307    {
308        let idx_s = idx_s_start + k;
309        if verboser.is_enabled(Verbosity::Trace) {
310            crate::verbosity_log!(
311                Verbosity::Trace,
312                "\tForward pass at k = {k} (idx_s = {idx_s}): backward interval {a_min_curr_} <= a[k] <= {a_max_curr_}, a_prev = {a_prev}."
313            );
314        }
315
316        a_b.clear();
317        problem
318            .constraints
319            .fill_acc_topp2::<true>(&mut a_b, idx_s - 1);
320        // a_b.0 * a[k] + a_b.1 * a[k-1] <= a_b.2
321        // Preserve the raw interval.  Any exact inversion is sent through the
322        // cold 2D certificate below instead of being folded to an unchecked
323        // midpoint by LP-1D's AUTONAN tolerance path.
324        let (mut a_max_curr, mut a_min_curr) = lp_1d::<false>(
325            a_b.iter().map(|&coeffs| {
326                // coeffs.0 * a_curr  + coeffs.1* a_prev <= coeffs.2
327                // coeffs.0 * a_curr <= coeffs.2 - coeffs.1 * a_prev
328                (coeffs.0, coeffs.2 - coeffs.1 * a_prev)
329            }),
330            &LpToleranceOptions::with_feas_tol(options.lp_feas_tol),
331        );
332
333        if verboser.is_enabled(Verbosity::Trace) {
334            crate::verbosity_log!(
335                Verbosity::Trace,
336                "\t\tForward LP result before clipping: {a_min_curr} <= a[k] <= {a_max_curr}."
337            );
338        }
339
340        // The clipping below destroys the raw one-step range, so keep a copy.
341        let (a_max_fwd, a_min_fwd) = (a_max_curr, a_min_curr);
342        // `f64::min`/`max` deliberately return the non-NaN operand.  Remember
343        // an infeasible 1D result before clipping, otherwise the finite
344        // backward bounds would silently turn `(NaN, NaN)` into a seemingly
345        // valid interval.
346        let forward_lp_failed = a_max_fwd.is_nan() || a_min_fwd.is_nan();
347        a_max_curr = a_max_curr.min(a_max_curr_);
348        a_min_curr = a_min_curr.max(a_min_curr_);
349        if verboser.is_enabled(Verbosity::Trace) {
350            crate::verbosity_log!(
351                Verbosity::Trace,
352                "\t\tAfter clipping with backward reachable set: {a_min_curr} <= a[k] <= {a_max_curr}."
353            );
354        }
355
356        if forward_lp_failed || a_max_curr < a_min_curr {
357            let recovered = if a_max_curr_.is_finite() && a_min_curr_.is_finite() {
358                recover_forward_intersection(
359                    &a_b,
360                    a_prev,
361                    (a_min[k - 1], a_max[k - 1]),
362                    (a_min_curr_, a_max_curr_),
363                    problem.constraints.amax_unchecked(idx_s - 1),
364                    problem.constraints.amax_unchecked(idx_s),
365                    options,
366                )
367            } else {
368                None
369            };
370            if let Some((a_recovered, worst_normalized, worst_raw)) = recovered {
371                if verboser.is_enabled(Verbosity::Debug) {
372                    crate::verbosity_log!(
373                        Verbosity::Debug,
374                        "Recovered the forward step at idx_s = {idx_s}: a[k] = {a_recovered}, max normalized violation = {worst_normalized:.6e}, max raw violation = {worst_raw:.6e}."
375                    );
376                }
377                a_max_curr = a_recovered;
378                a_min_curr = a_recovered;
379            } else if !forward_lp_failed
380                && !matches!(
381                    approx_order(
382                        a_max_curr,
383                        a_min_curr,
384                        // Both endpoints are differences of terms at the scale
385                        // of `a_prev`, so their rounding is inherited from it.
386                        options
387                            .a_cmp_abs_tol
388                            .max(options.a_cmp_rel_tol * a_prev.abs()),
389                        options.a_cmp_rel_tol,
390                    ),
391                    ApproxOrdering::Less
392                )
393            {
394                // An inversion within the comparison tolerance is a degenerate
395                // interval, not an empty one.
396                let a_degenerate = 0.5 * (a_max_curr + a_min_curr);
397                if verboser.is_enabled(Verbosity::Debug) {
398                    crate::verbosity_log!(
399                        Verbosity::Debug,
400                        "The forward reachable interval at idx_s = {idx_s} is inverted by {:.3e} at scale {:.3e}; treated as the degenerate point a[k] = {a_degenerate}.",
401                        a_min_curr - a_max_curr,
402                        a_prev.abs()
403                    );
404                }
405                a_max_curr = a_degenerate;
406                a_min_curr = a_degenerate;
407            } else {
408                // `a_prev` is `a_max_curr` of the previous step, hence already
409                // `<= a_max[k - 1]`.  So an empty intersection here can only mean
410                // the backward set is not self-consistent: its own cap at `k - 1`
411                // cannot reach its own cap at `k`.
412                let err = CoppError::Infeasible(
413                    "topp2_ra".into(),
414                    format!(
415                        "The reachable set is empty at index {} during the forward pass where a_max = {}, a_min = {}",
416                        idx_s_start + k,
417                        a_max_curr,
418                        a_min_curr
419                    ),
420                );
421                if verboser.is_enabled(Verbosity::Debug) {
422                    crate::verbosity_log!(Verbosity::Debug, "{err:?}");
423                } else if verboser.is_enabled(Verbosity::Summary) {
424                    crate::verbosity_log!(
425                        Verbosity::Summary,
426                        "topp2_ra: the forward pass failed at index {idx_s} due to infeasibility."
427                    );
428                }
429                return Err(err);
430            }
431        }
432
433        if a_max_curr.is_infinite() {
434            let err = CoppError::Unbounded(
435                "topp2_ra".into(),
436                format!(
437                    "The reachable set is unbounded at index {} during the forward pass where a_max = {}",
438                    idx_s_start + k,
439                    a_max_curr
440                ),
441            );
442            if verboser.is_enabled(Verbosity::Debug) {
443                crate::verbosity_log!(Verbosity::Debug, "{err:?}");
444            } else if verboser.is_enabled(Verbosity::Summary) {
445                crate::verbosity_log!(
446                    Verbosity::Summary,
447                    "topp2_ra: the forward pass failed at index {idx_s} due to unboundedness."
448                );
449            }
450            return Err(err);
451        }
452
453        if verboser.is_enabled(Verbosity::Debug)
454            && matches!(
455                approx_order(
456                    a_max_curr,
457                    a_min_curr,
458                    options.a_cmp_abs_tol,
459                    options.a_cmp_rel_tol,
460                ),
461                ApproxOrdering::Equal
462            )
463        {
464            crate::verbosity_log!(
465                Verbosity::Debug,
466                "The one-step forward reachable set at k = {k} (idx_s = {idx_s}) is degenerate since a_max and a_min are approximately equal at {}.",
467                0.5 * (a_max_curr + a_min_curr)
468            );
469        }
470
471        a_prev = a_max_curr;
472        *a_curr = a_prev;
473
474        if verboser.is_enabled(Verbosity::Trace) {
475            crate::verbosity_log!(
476                Verbosity::Trace,
477                "\t\tSelected maximal feasible state: a[k] = {a_prev}."
478            );
479        }
480    }
481
482    if verboser.is_enabled(Verbosity::Summary) {
483        crate::verbosity_log!(
484            Verbosity::Summary,
485            "topp2_ra: total elapsed time = {}.\n",
486            format_duration_human(verboser.elapsed())
487        );
488    }
489
490    Ok(a)
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use crate::copp::InterpolationMode;
497    use crate::copp::constraints::Constraints;
498    use crate::copp::copp2::stable::basic::{
499        Topp2ProblemBuilder, a_to_b_topp2, s_to_t_topp2, t_to_s_topp2,
500    };
501    use crate::copp::copp2::stable::reach_set2::ReachSet2OptionsBuilder;
502    use crate::path::{
503        Path, SplineConfig, add_symmetric_axial_limits_for_test, lissajous_path_for_test,
504    };
505    use crate::robot::robot_core::Robot;
506    use nalgebra::DMatrix;
507    use std::time::{Duration, Instant};
508
509    #[test]
510    fn test_topp2_ra() -> Result<(), CoppError> {
511        run_test_topp2_ra_repeated(1, false)
512    }
513
514    #[test]
515    fn test_recovery_numeric_contract_at_extremes() {
516        let options = ReachSet2OptionsBuilder::new().build().unwrap();
517
518        assert!(
519            recover_forward_intersection(
520                &[],
521                0.0,
522                (0.0, 0.0),
523                (0.0, 1.0),
524                f64::NEG_INFINITY,
525                1.0,
526                &options,
527            )
528            .is_none(),
529            "a non-finite effective predecessor interval must be rejected"
530        );
531
532        assert!(
533            recover_forward_intersection(
534                &[(0.0, 1.0, 1.0)],
535                2.0,
536                (0.0, 2.0),
537                (0.0, 2.0),
538                2.0,
539                2.0,
540                &options,
541            )
542            .is_none(),
543            "a violation independent of the current state must not be swallowed"
544        );
545
546        let folded_delta = 0.25 * options.a_cmp_abs_tol;
547        assert!(
548            recover_forward_intersection(
549                &[],
550                1.0,
551                (1.0 + folded_delta, 1.0 - folded_delta),
552                (0.0, 1.0),
553                2.0,
554                1.0,
555                &options,
556            )
557            .is_some(),
558            "a predecessor interval folded within a_cmp_tol remains admissible"
559        );
560
561        // Squaring MIN_POSITIVE underflows to zero.  A direct sqrt(a*a+b*b)
562        // would therefore discard this nonzero row and incorrectly keep 2.0.
563        let (a_curr, normalized, _) = recover_forward_intersection(
564            &[(f64::MIN_POSITIVE, 0.0, 0.0)],
565            0.0,
566            (0.0, 0.0),
567            (0.0, 2.0),
568            2.0,
569            2.0,
570            &options,
571        )
572        .expect("stable row normalization must retain a finite subnormal-scale row");
573        assert!(a_curr <= options.lp_feas_tol);
574        assert!(normalized <= options.lp_feas_tol);
575
576        // A unit-normal tolerance alone would allow roughly 1e4 of raw
577        // violation on this row.  The physical mixed cap keeps it below 1e-8.
578        let (a_curr, normalized, raw) = recover_forward_intersection(
579            &[(1.0e12, 0.0, 1.0)],
580            0.0,
581            (0.0, 0.0),
582            (0.0, 1.0),
583            1.0,
584            1.0,
585            &options,
586        )
587        .expect("the physical residual cap still leaves a nonempty interval");
588        assert!(a_curr <= (1.0 + options.lp_feas_tol) / 1.0e12);
589        assert!(normalized <= options.lp_feas_tol);
590        assert!(raw <= options.lp_feas_tol);
591    }
592
593    fn constraints_with_first_edge_rows(
594        rows: &[(f64, f64, f64)],
595        ds: f64,
596        path_cap: f64,
597    ) -> Constraints {
598        let mut constraints = Constraints::with_capacity(1, 3);
599        constraints.with_s(&[0.0, ds, 2.0 * ds][..]).unwrap();
600        constraints
601            .with_constraint_1order(&[path_cap, path_cap, path_cap][..], 0)
602            .unwrap();
603
604        // At the left station, fill_acc_topp2::<true> emits
605        // (acc_b/(2ds), acc_a-acc_b/(2ds), rhs).  Invert that map so the
606        // generated problem contains the requested edge rows exactly up to
607        // the multiplication/division round trip under test.
608        let acc_a = DMatrix::from_iterator(rows.len(), 1, rows.iter().map(|r| r.0 + r.1));
609        let acc_b = DMatrix::from_iterator(rows.len(), 1, rows.iter().map(|r| 2.0 * ds * r.0));
610        let acc_max = DMatrix::from_iterator(rows.len(), 1, rows.iter().map(|r| r.2));
611        constraints
612            .with_constraint_2order(
613                &acc_a.as_view(),
614                &acc_b.as_view(),
615                &acc_max.as_view(),
616                0,
617                false,
618            )
619            .unwrap();
620        constraints
621    }
622
623    fn first_edge_row_after_round_trip(
624        (coef_curr, coef_prev, rhs): (f64, f64, f64),
625        ds: f64,
626    ) -> (f64, f64, f64) {
627        let acc_a = coef_curr + coef_prev;
628        let acc_b = 2.0 * ds * coef_curr;
629        let coef_curr = acc_b / (2.0 * ds);
630        (coef_curr, acc_a - coef_curr, rhs)
631    }
632
633    #[test]
634    fn test_topp2_ra_recovery_distribution_stress() {
635        // Each three-station problem is a complete TOPP2 run.  The grid is
636        // deterministic so a failing tuple is exactly reproducible, while its
637        // axes cover state magnitude, ds scaling, cancelled coefficient size,
638        // condition number, interval-gap behavior, and all row permutations.
639        let options = ReachSet2OptionsBuilder::new().build().unwrap();
640        let epsilon = options.lp_feas_tol;
641        let base_cases = [
642            (48.0, 32.0, 4.0),
643            (100.0, 64.0, 4.0),
644            (277.77777777777777, 200.0, 6.0),
645            (6144.0, 4096.0, 8.0),
646        ];
647        let ds_exponents = [-20, 0, 20];
648        let row_scale_exponents = [0, 4, 8];
649        let condition_exponents = [30, 35, 40];
650        let gap_factors = [0.25, 0.75, 1.25, 32.0];
651        let permutations = [
652            [0, 1, 2],
653            [0, 2, 1],
654            [1, 0, 2],
655            [1, 2, 0],
656            [2, 0, 1],
657            [2, 1, 0],
658        ];
659        let mut midpoint_cases = 0usize;
660        let mut masked_nan_cases = 0usize;
661        let mut total_cases = 0usize;
662
663        for &(path_cap, a_prev, tiny_upper_offset) in &base_cases {
664            let robust_upper = path_cap - a_prev;
665            for &ds_exponent in &ds_exponents {
666                let ds = 2.0_f64.powi(ds_exponent);
667                for &row_scale_exponent in &row_scale_exponents {
668                    let row_rhs = 300.0 * (a_prev / 64.0) * 2.0_f64.powi(row_scale_exponent);
669                    for &condition_exponent in &condition_exponents {
670                        let condition = 2.0_f64.powi(condition_exponent);
671                        let intended_tiny_upper = robust_upper - tiny_upper_offset;
672                        let tiny = row_rhs / (condition * a_prev + intended_tiny_upper);
673                        let coef_prev = tiny * condition;
674                        let tiny_row = (tiny, coef_prev, row_rhs);
675                        let actual_tiny = first_edge_row_after_round_trip(tiny_row, ds);
676                        let actual_tiny_upper =
677                            (actual_tiny.2 - actual_tiny.1 * a_prev) / actual_tiny.0;
678
679                        assert!(
680                            actual_tiny_upper.is_finite()
681                                && actual_tiny_upper > 0.0
682                                && actual_tiny_upper < robust_upper,
683                            "bad generated tiny upper: cap={path_cap:e}, prev={a_prev:e}, ds_exp={ds_exponent}, row_scale_exp={row_scale_exponent}, cond_exp={condition_exponent}, upper={actual_tiny_upper:e}"
684                        );
685
686                        for &gap_factor in &gap_factors {
687                            let requested_lower = actual_tiny_upper + gap_factor * epsilon;
688                            let canonical_rows = [
689                                (1.0, 1.0, path_cap),
690                                tiny_row,
691                                (-1.0, 1.0, a_prev - requested_lower),
692                            ];
693                            for (permutation_id, permutation) in permutations.iter().enumerate() {
694                                let rows = [
695                                    canonical_rows[permutation[0]],
696                                    canonical_rows[permutation[1]],
697                                    canonical_rows[permutation[2]],
698                                ];
699                                let constraints =
700                                    constraints_with_first_edge_rows(&rows, ds, path_cap);
701                                let problem = Topp2ProblemBuilder::with_constraint(
702                                    &constraints,
703                                    (0, 2),
704                                    (a_prev, 0.0),
705                                )
706                                .build()
707                                .unwrap();
708                                let reach = reach_set2_backward(&problem, &options).unwrap();
709                                let mut actual_rows = Vec::new();
710                                constraints.fill_acc_topp2::<true>(&mut actual_rows, 0);
711                                let lp_tol = LpToleranceOptions::with_feas_tol(epsilon);
712                                let reduced_rows =
713                                    || actual_rows.iter().map(|&(c, d, rhs)| (c, rhs - d * a_prev));
714                                let (raw_max, raw_min) = lp_1d::<false>(reduced_rows(), &lp_tol);
715                                assert!(
716                                    raw_max < raw_min,
717                                    "generated interval is not inverted: cap={path_cap:e}, prev={a_prev:e}, ds_exp={ds_exponent}, row_scale_exp={row_scale_exponent}, cond_exp={condition_exponent}, gap_factor={gap_factor}, perm={permutation_id}, raw=[{raw_min:e},{raw_max:e}]"
718                                );
719
720                                // Recreate the previous forward control flow.  For a
721                                // sub-tolerance gap it folds the exact inversion to a
722                                // midpoint; otherwise it returns NaNs, which f64
723                                // clipping silently replaces by the backward interval.
724                                let (legacy_max, legacy_min) =
725                                    lp_1d::<true>(reduced_rows(), &lp_tol);
726                                let legacy_choice = legacy_max.min(reach.a_max[1]);
727                                let legacy_lower = legacy_min.max(reach.a_min[1]);
728                                assert!(
729                                    legacy_choice >= legacy_lower,
730                                    "legacy fixture did not survive clipping: cap={path_cap:e}, prev={a_prev:e}, ds_exp={ds_exponent}, row_scale_exp={row_scale_exponent}, cond_exp={condition_exponent}, gap_factor={gap_factor}, perm={permutation_id}"
731                                );
732                                if legacy_max.is_nan() && legacy_min.is_nan() {
733                                    masked_nan_cases += 1;
734                                    let gross_residual = legacy_choice + a_prev - path_cap;
735                                    assert!(
736                                        gross_residual > 0.5 * a_prev,
737                                        "legacy NaN masking was not grossly infeasible: residual={gross_residual:e}"
738                                    );
739                                } else {
740                                    midpoint_cases += 1;
741                                    assert_eq!(legacy_max, legacy_min);
742                                    assert!(
743                                        robust_upper - legacy_choice > 0.5 * tiny_upper_offset,
744                                        "legacy midpoint was not observably suboptimal: legacy={legacy_choice:e}, robust_upper={robust_upper:e}"
745                                    );
746                                }
747
748                                let profile = topp2_ra(&problem, &options).unwrap_or_else(|error| {
749                                    panic!(
750                                        "new solver failed: cap={path_cap:e}, prev={a_prev:e}, ds_exp={ds_exponent}, row_scale_exp={row_scale_exponent}, cond_exp={condition_exponent}, gap_factor={gap_factor}, perm={permutation_id}: {error:?}"
751                                    )
752                                });
753                                assert_eq!(profile.len(), 3);
754                                assert_eq!(profile[0], a_prev);
755                                assert_eq!(profile[2], 0.0);
756
757                                // The robust row x + a_prev <= path_cap is the
758                                // active maximizer after both tolerance policies are
759                                // applied.  It gives an analytic oracle independent
760                                // of LP-1D and the recovery routine.
761                                let oracle = robust_upper + 2.0_f64.sqrt() * epsilon;
762                                let oracle_roundoff = 512.0 * f64::EPSILON * oracle.abs().max(1.0);
763                                assert!(
764                                    (profile[1] - oracle).abs() <= oracle_roundoff,
765                                    "not the largest certified state: got={:e}, oracle={oracle:e}, allowance={oracle_roundoff:e}, cap={path_cap:e}, prev={a_prev:e}, ds_exp={ds_exponent}, row_scale_exp={row_scale_exponent}, cond_exp={condition_exponent}, gap_factor={gap_factor}, perm={permutation_id}",
766                                    profile[1]
767                                );
768
769                                for &(coef_curr, coef_prev, rhs) in &actual_rows {
770                                    let raw = coef_curr * profile[1] + coef_prev * profile[0] - rhs;
771                                    let norm = coef_curr.hypot(coef_prev);
772                                    let normalized = raw / norm;
773                                    let normalized_second_order = (coef_curr / norm) * profile[1]
774                                        + (coef_prev / norm) * profile[0]
775                                        - rhs / norm;
776                                    assert!(
777                                        normalized <= epsilon
778                                            && normalized_second_order <= epsilon
779                                            && raw <= epsilon * rhs.abs().max(1.0),
780                                        "uncertified row: row=({coef_curr:e},{coef_prev:e},{rhs:e}), raw={raw:e}, norm1={normalized:e}, norm2={normalized_second_order:e}, profile={:?}",
781                                        profile
782                                    );
783                                }
784                                let (path_exceed, physical_exceed) =
785                                    constraints.exceed_topp2(0, &profile);
786                                assert!(path_exceed <= 0.0);
787                                assert!(
788                                    physical_exceed <= 1.0e-5,
789                                    "physical audit failed: exceed={physical_exceed:e}, profile={profile:?}, cap={path_cap:e}, prev={a_prev:e}, ds_exp={ds_exponent}, row_scale_exp={row_scale_exponent}, cond_exp={condition_exponent}, gap_factor={gap_factor}, perm={permutation_id}"
790                                );
791                                total_cases += 1;
792                            }
793                        }
794                    }
795                }
796            }
797        }
798
799        assert_eq!(total_cases, 2592);
800        assert_eq!(midpoint_cases, 1296);
801        assert_eq!(masked_nan_cases, 1296);
802    }
803
804    #[test]
805    #[ignore = "bindings"]
806    fn test_topp2_ra_bindings_parity() -> Result<(), CoppError> {
807        let dim = 3;
808        let num_waypoints = 8;
809        let n: usize = 201;
810        let pi = std::f64::consts::PI;
811
812        let waypoints = DMatrix::<f64>::from_fn(dim, num_waypoints, |axis, j| {
813            let s = j as f64 / (num_waypoints - 1) as f64;
814            match axis {
815                0 => 0.20 * (2.0 * pi * s).sin(),
816                1 => 0.15 * (1.5 * pi * s).cos(),
817                2 => 0.10 * s * (1.0 - s),
818                _ => unreachable!("dimension is fixed to 3"),
819            }
820        });
821        let path = Path::from_waypoints_interpolating(&waypoints, SplineConfig::default())?;
822        let s = DMatrix::<f64>::from_fn(1, n, |_, j| j as f64 / (n - 1) as f64);
823
824        let mut robot = Robot::with_capacity(dim, n);
825        robot
826            .with_s(&s.as_view())?
827            .with_q_from_path_2nd(&path, 0, n)?
828            .with_q_from_path_3rd(&path, 0, n)?;
829        add_symmetric_axial_limits_for_test(&mut robot, 10.0, 50.0, Some(1000.0))?;
830
831        let topp2_problem = Topp2ProblemBuilder::new(&robot, (0, n - 1), (0.0, 0.0)).build()?;
832        let options = ReachSet2OptionsBuilder::new().build()?;
833        let a_profile = topp2_ra(&topp2_problem, &options)?;
834
835        let (t_final, t_s) = s_to_t_topp2(s.as_slice(), &a_profile, 0.0)?;
836        let s_t = t_to_s_topp2(
837            s.as_slice(),
838            &a_profile,
839            &t_s,
840            InterpolationMode::UniformTimeGrid(0.0, 1e-3, true),
841        )?;
842
843        crate::verbosity_log!(
844            Verbosity::Summary,
845            "TOPP2-RA Rust bindings parity test: t_final={:.17}, s_t.len={}",
846            t_final,
847            s_t.len()
848        );
849
850        Ok(())
851    }
852
853    /// Conditions: release, --include-ignored, CPU = Intel(R) Core(TM) Ultra 9 285K.
854    /// Average over 10000 experiments: tc_topp2_ra = 0.286978 ms, tc_interpolation = 0.002001 ms, t_final = 6.168578
855    #[test]
856    #[ignore = "slow"]
857    fn test_topp2_ra_robust() -> Result<(), CoppError> {
858        run_test_topp2_ra_repeated(10000, true)
859    }
860
861    fn run_test_topp2_ra_repeated(n_exp: usize, flag_print_step: bool) -> Result<(), CoppError> {
862        let mut tc_sum_ra = Duration::ZERO;
863        let mut tc_sum_interpolation = Duration::ZERO;
864        let mut t_final_sum = 0.0;
865
866        let options = ReachSet2OptionsBuilder::new()
867            .lp_feas_tol(1E-9)
868            .a_cmp_abs_tol(1E-9)
869            .a_cmp_rel_tol(1E-9)
870            .verbosity(Verbosity::Summary)
871            .build()?;
872
873        for i_exp in 0..n_exp {
874            let dim = 7;
875            let n: usize = 1000;
876            let mut robot = Robot::with_capacity(dim, n);
877
878            let mut rng = rand::rng();
879            let (s, path, _, _) = lissajous_path_for_test(dim, n, &mut rng).map_err(|e| {
880                CoppError::InvalidInput("lissajous_path_for_test".into(), e.to_string())
881            })?;
882            robot
883                .with_s(&s.as_view())?
884                .with_q_from_path_2nd(&path, 0, n)?;
885            add_symmetric_axial_limits_for_test(&mut robot, 1.0, 1.0, None)?;
886
887            let start = Instant::now();
888            let topp2_problem = Topp2ProblemBuilder::new(&robot, (0, n - 1), (0.0, 0.0)).build()?;
889            let a_profile = topp2_ra(&topp2_problem, &options)?;
890            let tc_topp2_ra = start.elapsed();
891
892            let b_profile = a_to_b_topp2(s.as_slice(), &a_profile)?;
893            assert!(
894                !izip!(
895                    s.as_slice().windows(2),
896                    a_profile.windows(2),
897                    b_profile.iter()
898                )
899                .any(|(s_pair, a_pair, b)| {
900                    let ds_double = 2.0 * (s_pair[1] - s_pair[0]);
901                    let db = (a_pair[1] - a_pair[0]) / ds_double;
902                    (*b - db).abs() > 1e-3
903                }),
904                "b_profile generation failed!"
905            );
906
907            let start = Instant::now();
908            let (t_final, t_s) = s_to_t_topp2(s.as_slice(), &a_profile, 0.0)?;
909            assert_eq!(t_s.len(), s.ncols());
910            let tc_interpolation = start.elapsed();
911            let s_t = t_to_s_topp2(
912                s.as_slice(),
913                &a_profile,
914                &t_s,
915                InterpolationMode::UniformTimeGrid(0.0, 1E-3, true),
916            )?;
917
918            tc_sum_ra += tc_topp2_ra;
919            tc_sum_interpolation += tc_interpolation;
920            t_final_sum += t_final;
921
922            if flag_print_step && ((i_exp + 1) % 100 == 0) {
923                crate::verbosity_log!(
924                    Verbosity::Summary,
925                    "Exp #{}: tc_topp2_ra = {:.4} ms, tc_interpolation = {:.4} ms, t_final = {:.4} s, s_t.len() = {}",
926                    i_exp + 1,
927                    tc_topp2_ra.as_secs_f64() * 1E3,
928                    tc_interpolation.as_secs_f64() * 1E3,
929                    t_final,
930                    s_t.len()
931                );
932            }
933        }
934
935        crate::verbosity_log!(
936            Verbosity::Summary,
937            "Average over {} experiments: tc_topp2_ra = {:.6} ms, tc_interpolation = {:.6} ms, t_final = {:.6}",
938            n_exp,
939            tc_sum_ra.as_secs_f64() * 1E3 / n_exp as f64,
940            tc_sum_interpolation.as_secs_f64() * 1E3 / n_exp as f64,
941            t_final_sum / n_exp as f64
942        );
943
944        Ok(())
945    }
946}